Decide admin status from Firestore, not from the token claim - #182
Merged
Conversation
A demoted admin kept full admin power for up to an hour, and could use it to
re-promote themselves permanently.
isAdmin() returned true on a positive `admin` custom claim alone, as a cheap
path with no Firestore read. Nothing revokes an already-issued id token when
onAdminStateWritten clears that claim on demotion, and rules verify only a
JWT's signature and expiry — not whether the claim inside it is still true,
nor even that the Auth user still exists. So for the remaining life of their
token, an ex-admin still satisfied isAdmin().
That alone was an hour of banning users, reading every feedback submitter's
email, and writing arbitrary fields to any user document (the isAdmin() branch
on users/{uid} update has no field allowlist). `role` being writable with it
made it permanent: write {role:'admin'} back to your own admin/state on the
stale claim, and the trigger re-issues the claim for real.
The direction of trust is what was wrong, not the caching. isNotBanned()
trusts a positive `banned` claim too, but a stale ban claim keeps someone
banned slightly too long — it fails safe. A stale admin claim fails open.
isAdmin() now reads admin/state, which is what the callables have always done
via getNotificationActor, so the two layers agree. exists() stays first so the
helper returns false rather than raising for a user with no such document —
an error would propagate through `isAdmin() || (own record)` and lock ordinary
users out of their own reports and feedback, which is #150.
Also: no client write may introduce or change `role`, not even a real admin's.
Promotion is an owner operation through the console or the Admin SDK, both of
which bypass rules, and the app has never written it — blockUserByAdmin, the
only caller, writes the three ban fields. One compromised admin session can no
longer mint more admins.
`role` deliberately STAYS in isAllowedAdminStateWrite's key allowlist. That
list says which keys may exist, and request.resource.data on an update is the
whole post-write document, not the changed keys — so dropping it would not
have stopped a client writing role, it would have stopped admins banning
anyone the Admin SDK had already given one. That is the same trap
hasSafePublicLocation is currently stuck in. The restriction is expressed
per-operation instead: absent on create, unaffected on update.
Revoking refresh tokens was considered and is not the fix: revocation stops
the client obtaining a NEW id token, but the current one keeps satisfying
rules until it expires.
Three new tests fail on the old rules and pass on these. Two more pass on
both, on purpose: one pins that a freshly promoted admin whose token carries
no claim is still recognised (the propagation-lag case the claim shortcut
existed for), and one pins that banning a user whose document already carries
a role still works — the trap above.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A demoted admin kept full admin power for up to an hour, and could use it to re-promote themselves permanently.
The chain
firestore.rules:30 returned true on a positive
admincustom claim alone, as a cheap path with no Firestore read:functions/src/users.ts:245-250 —
onAdminStateWrittenclears that claim on demotion, but nothing revokes the already-issued id token.grep -rn revokeRefreshTokens src/ functions/src/ scripts/ functions/scripts/is empty. Rules verify only a JWT's signature and expiry — not whether the claim inside it is still true, nor even that the Auth user still exists.firestore.rules:89 put
rolein the client-writable allowlist, and firestore.rules:201-202 gated that document onisAdmin()alone.So: the owner demotes an admin in the console. Within the next hour that person — a non-admin as far as the database is concerned — runs
setDoc(doc(db,'users',<ownUid>,'admin','state'), {role:'admin'}, {merge:true}). The stale claim satisfiesisAdmin(),roleis allowlisted, the write lands, andonAdminStateWrittenre-issuesadmin: truefor real. The demotion is undone by the person demoted, and it stays undone.Even without the re-promotion, that hour bought: banning any user, reading every feedback submitter's email (moderation.ts:147), deleting anyone's notifications, and writing arbitrary fields to any user document — the
isAdmin()branch at firestore.rules:135 has no field allowlist.The callable layer was never affected:
getNotificationActorreadsadmin/statefrom Firestore, socaller.role !== "admin"was always current. This was rules-only.Why the direction of trust was the bug
isNotBanned()trusts a positivebannedclaim too — and that's fine:bannedtrusted positivelyadmintrusted positivelyNot a caching problem. A direction problem.
The fix
isAdmin()readsadmin/state— what the callables have always done, so the two layers now agree.exists()stays first so the helper returns false rather than raising for a user with no such document; an evaluation error would propagate throughisAdmin() || (own record)and lock ordinary users out of their own reports and feedback. That's #150, and its test still passes.Cost is one document read wherever
isAdmin()is actually reached. It sits behind a short-circuit on every hot path —isOwner(userId) || isAdmin(),(isOwner && isAllowedUserUpdate()) || isAdmin()— so the read lands on admin surfaces and on reports/feedback reads by their own author. Low traffic.isNotBanned()already pays a comparable read on every like and bookmark.Second change: no client write may introduce or change
role, not even a real admin's. Promotion is an owner operation via the console or Admin SDK, both of which bypass rules, and the app has never written it — admin.ts:235-239, the only caller, writes the three ban fields. One compromised admin session can no longer mint more admins.A trap I did not walk into
roledeliberately stays inisAllowedAdminStateWrite's allowlist. That list says which keys may exist, andrequest.resource.dataon an update is the whole post-write document, not the changed keys. Droppingrolewould not have stopped a client writing it — it would have stopped admins banning anyone the Admin SDK had already given one.That is precisely the bug
hasSafePublicLocationis stuck in today (audit finding 4: a legacylocation.latwedges every write to that user doc). Same shape, one file apart. So the restriction is expressed per-operation instead: absent on create, unaffected on update. There's a test pinning that banning a role-carrying user still works.Considered and rejected
revokeRefreshTokenson demotion. It stops the client obtaining a new id token, but Firestore rules validate only signature and expiry, so the current one keeps satisfying them until it expires. It would have narrowed nothing that the document read doesn't close completely.Tests
tests/rules/users.test.ts, 15 → 20 tests.Three fail on the old rules and pass on these:
The third is the exploit end to end: seed
admin/state = {role:'user'}(demoted), hand the context anadmin: trueclaim, try to writerole:'admin'back both ways.Two more pass on both, on purpose — they're boundary guards, not the red signal:
isAdmin()reads the document rather than AND-ing it with the claim)rolestill worksFull local run: rules 50/50, functions
test:emulator73/73, functions lint/build/typecheck:test clean, roottypecheck:testsclean.Deploy
Rules are the only part with runtime effect — the
users.tschange is a comment correcting what it says about the claim/rules relationship.🤖 Generated with Claude Code